Skip to content

Trim per-request work in the HttpClient adapter - #4561

Open
jnunn-aws wants to merge 6 commits into
developmentfrom
jonatnun/http-adapter-trims
Open

jnunn-aws wants to merge 6 commits into
developmentfrom
jonatnun/http-adapter-trims

Conversation

@jnunn-aws

@jnunn-aws jnunn-aws commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Description

  • HttpClientResponseData: copy each response header once from the
    enumerated values instead of re-looking every key up with TryGetValues;
    build GetHeaderNames lazily from the dictionary and answer
    IsHeaderPresent from the dictionary instead of a second HashSet.
  • User-Agent: reuse one of the last four generated strings per HttpHandler when the
    ordered components and feature ids match exactly (a client alternating
    sync and async calls, or operations with different feature ids, keeps one
    entry per shape); merge request-level
    UA details without the intermediate join/enumerator; avoid the UTF-8
    byte[] in the 1 KB truncation check.
  • HttpHandler: reuse the previously parsed request Uri when the composed
    URL string (and the canonicalization setting) is unchanged (non-BCL);
    skip attribute building and boxing in RecordHttpTelemetryData when the
    trace span and meter provider are the SDK's no-op ones; skip RequestSize boxing when RequestMetrics
    is disabled.
  • HttpWebRequestMessage: use the shared HttpMethod instances for the
    standard methods.

HttpAdapterTests expects the runtime's own exception type for
HttpMethod(null): netcoreapp3.1 throws ArgumentException where net472 and
.NET 5+ throw ArgumentNullException, and GetHttpMethod passes null through
unchanged.

Motivation and Context

x86 ARM64
ops/CPU-sec, this change alone +4.3% +6.8%

Measurement: Numbers come from the serde ops/CPU-sec runner (sdk/test/Performance/SerdeBenchmarks, --cpu-time, stub transport), geomean of 71 cases, 3 interleaved runs. Instances: m7i.metal-24xl (x86) and m7g.metal (ARM64). Settings: net8.0, 20,000-iteration warmup, measured on development 8956121, which has the same code as this branch's base for these files. Each change is measured alone against that base. Both metal runs measured an earlier revision of this PR that kept one User-Agent string per client instead of four. On feature/4.1 (337f357, net10.0, 2,000-iteration warmup, 3 interleaved rounds, same instances) this change gained +5.8% on x86 and +5.1% on ARM. The final run at the harness defaults (net10.0, 100,000 warmup, stub and https) is tracked separately.

Clients that alternate request shapes. A cache miss costs no more than today's code. In a development-host run (one client, alternating GetObject keys and/or User-Agent components, stub transport, 9 interleaved reps), every alternating pattern was 6.6–7.8% cheaper per call than development, against 7.4% when nothing alternates. The −3.4% on awsQuery_GetMetricDataResponse_S in the development run didn't reproduce on feature/4.1 (+4.9% x86, +3.9% ARM), where the worst of the 71 cases was −0.9% on x86, within the noise.

Testing

  • New cases in HttpHandlerTests and UserAgentTests: response headers against explicit expected values (multi-value, empty, invalid, case-only collision, cached header names, missing header); Uri reuse and canonicalization changes; RequestSize recorded only when request metrics are enabled; standard HttpMethod reuse; HTTP metrics and span attributes with custom providers; User-Agent reuse with alternating shapes and with more shapes than cache entries.
  • Core unit tests pass on net8.0 (1,465) and netcoreapp3.1 (1,435), and on net8.0 (1,513) and net10.0 (1,512) with this change applied to feature/4.1 60dfa62.

Breaking Changes Assessment

None to the public API. HttpClientResponseData (Amazon.Runtime.Internal.Transform) no longer throws when a response header and a content header differ only in case (the response header's value is kept), and IsHeaderPresent returns false instead of throwing NullReferenceException on event-stream message data.

Credit

@dscpinheiro independently arrived at the same response-side change in 9b07f37: copying each response header once and answering IsHeaderPresent/GetHeaderNames from the header dictionary.

Dry-runs

  • DotNet Dry-run ID: c30c786d-7644-4362-8263-158a3ee4e39a
    • Completed successfully
  • PowerShell Dry-run ID: 1f4ee6b1-6803-4264-9eeb-e94bad3c244f
    • Completed successfully

Checklist

  • My code follows the code style of this project
  • I have added tests to cover my changes
  • All new and existing tests passed

License

  • I confirm that this pull request can be released under the Apache 2 license

- HttpClientResponseData: copy each response header once from the
  enumerated values instead of re-looking every key up with TryGetValues;
  build GetHeaderNames lazily from the dictionary and answer
  IsHeaderPresent from the dictionary instead of a second HashSet.
- User-Agent: reuse the last generated string per HttpHandler when the
  ordered components and feature ids are unchanged; merge request-level
  UA details without the intermediate join/enumerator; avoid the UTF-8
  byte[] in the 1 KB truncation check.
- HttpHandler: reuse the previously parsed request Uri when the composed
  URL string (and the canonicalization setting) is unchanged (non-BCL);
  skip attribute building/boxing in RecordHttpTelemetryData when the
  meter/tracer providers are the built-in no-op ones; skip RequestSize
  boxing when RequestMetrics is disabled.
- HttpWebRequestMessage: use the shared HttpMethod instances for the
  standard methods; enumerate concrete Dictionary headers without boxing.

HttpAdapterTests expects the runtime's own exception type for
HttpMethod(null): netcoreapp3.1 throws ArgumentException where net472 and
.NET 5+ throw ArgumentNullException, and GetHttpMethod passes null through
unchanged.
@jnunn-aws
jnunn-aws marked this pull request as ready for review October 28, 2026 07:03
@jnunn-aws
jnunn-aws requested review from a team as code owners October 28, 2026 07:03
@jnunn-aws
jnunn-aws requested review from dscpinheiro and sichanyoo and removed request for a team October 28, 2026 07:03
Merge near-duplicate tests into data rows, assert literal User-Agent values,
and use the shared RecordingTelemetry.cs helpers. The response-header test
still compares against the previous copy algorithm.
Only tests passed a cached entry; HttpHandler.GetUserAgent already does the
matching through UserAgentDetails.Matches, so the method no longer takes one.
@dscpinheiro dscpinheiro added the perf Performance related improvements label Oct 30, 2026
}

var generated = userAgentDetails.CreateGeneratedUserAgent();
var slot = (Interlocked.Increment(ref _nextUserAgentCacheSlot) - 1) & (UserAgentCacheSize - 1);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious, did you measure this cache on its own? The PR description has one number for all five changes, and the lookup (seems to) walk two HashSets per entry (so I'm not sure the complexity - Volatile / Interlocked / etc. - is worth it...)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On its own, in a microbenchmark, a cache hit costs 30 ns against 413 ns to rebuild the string, so it saves about 0.4 µs per call. With the ops/CPU-sec harness I measured it together with the Uri reuse: on six small cases (9 interleaved rounds each), removing both raised the HttpHandler request stage by 0.4 to 3.5 µs per call, added about 1.1 KB of allocations per call, and dropped ops/CPU-sec by 2.1% (geometric mean). The slots are only written on a miss, so I'd like to keep it.

/// Returns the same Uri as <see cref="AmazonServiceClient.ComposeUrl(IRequest)"/>, reusing the previously
/// parsed Uri when the composed URL string is unchanged.
/// </summary>
internal Uri ComposeRequestUrl(IRequest request)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar question here: did you measure the Uri reuse on its own? The URL string is still built per call, so this only skips the Uri parse, and it costs two new ComposeUrl helpers, a ComposedUri class and three #if branches.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On its own, in a microbenchmark, skipping the Uri parse saves 0.25 to 0.3 µs per call. With the ops/CPU-sec harness I measured it together with the User-Agent cache: removing both cost 2.1% ops/CPU-sec on six small cases and about 1.1 KB more allocated per call in the HttpHandler request stage. So I'd like to keep it.

@@ -530,13 +548,25 @@ public void ConfigureRequest(IRequestContext requestContext)
/// <param name="headers">A dictionary of header names and values.</param>
public void SetRequestHeaders(IDictionary<string, string> headers)
{
foreach (var kvp in headers)
// Enumerate the concrete type when possible to avoid boxing the enumerator.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was there a measurable difference from this? It's one boxed enumerator per request, and we now have two copies of the loop.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No measurable difference. It saved 61 ns and 56 bytes per request in a microbenchmark and nothing visible end to end, so I went back to the single loop in 13d1447.


return string.Empty;
return values.FirstOrDefault();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the other comment about dictionary / duplicating loops: I'd be surprised if this had a significant difference... (I'd expect FirstOrDefault to already be optimized)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one does make a difference. On a 9-header S3 response, FirstOrDefault adds about 450 ns (50 ns per header) in a microbenchmark and 0.3 µs end to end. The larger saving is taking the value from the enumerated pair instead of looking each key up again with TryGetValues: 2.3 µs per response end to end, the largest single piece of this PR.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the broken record, but same comment as on the other two PRs: this is a lot of new test code for the change. We already have UserAgentTests and HttpHandlerTests, so new cases should go there.

RecordingTelemetry.cs is also the same file as in #4559 and ReferenceCopyHeaders just re-implements the old code as the expected value - a few explicit expected headers would be easier to follow.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the cases into UserAgentTests and HttpHandlerTests, dropped the ones that repeat existing coverage, and removed HttpAdapterTests.cs and RecordingTelemetry.cs in 13d1447 and 46bb8ab. The test diff went from 712 to 293 lines. The header test now checks explicit expected headers instead of ReferenceCopyHeaders, and the telemetry test uses two small private providers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

perf Performance related improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants